Repository navigation
fix(security): shared lessons print inert in the CLI; widths count wide glyphs - #153
Open
UltimaPhoenix wants to merge 1 commit into
Open
UltimaPhoenix wants to merge 1 commit into
UltimaPhoenix wants to merge 1 commit into
Conversation
…de glyphs A shared lesson is someone else's text, and the CLI printed it as-is: an imported title with ESC[2J cleared the screen on `import`, `lessons` and `lesson <id>`, a sender name could carry an OSC 8 link to javascript: or file:, and a summary could write the clipboard with OSC 52. - core/text.ts: one strip for escape sequences (CSI, OSC/DCS/APC/PM/SOS strings, 7- and 8-bit) and control characters (C0 but tab/newline, DEL, C1, bidi overrides and isolates, ZWSP, BOM, line separators). - importSharedLesson strips every string before comparing or storing and refuses a lesson left without a title, topic or id; the legacy JSON array form of `import` gets the same strip. - The CLI strips again before printing every lesson and course string (rows stored before this fix), plus topic names and the doctor's skip reason. - term.ts: grapheme-aware displayWidth (CJK and emoji = 2), used by tables and rules, so the 💡 row in `lessons` aligns; trustedLink (was link) wraps only plain http(s) URLs of at most 2 KiB; FORCE_COLOR is honoured (0/false off), TERM=dumb disables, NO_COLOR wins; termWidth and a left-anchored rule().
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a security fix. On
develop(and every release up to 2.7.1), the CLI writes text from a shared lesson to the terminal byte for byte. A shared lesson is someone else's text. Checked on a sandbox: afterdevcoach importof a crafted share code,Cache mounts survive rebuilds ESC[2J ESC[3J ESC[Hclears the screen and the scrollback whenimport,lessonsorlesson <id>prints it;ESC]8;;javascript:alert(1) BEL Ada ESC]8;; BELand a summary link tofile:///etc/passwdprint as live OSC 8 hyperlinks behind innocent text;lesson <id>prints it.The bytes were also stored as-is in
coaching.db, so every surface received them. The dashboard escapes HTML, so this is the terminal surface.Scope: terminal sanitising and width.
What changed
src/core/text.ts(new): one strip, a small scanner over code points. It removes escape sequences (CSI; OSC/DCS/APC/PM/SOS strings up to BEL or ST; other ESC sequences; 7-bit and 8-bit forms) and control characters (C0 except tab/newline, DEL, C1, bidi embeddings/overrides/isolates, LRM/RLM/ALM, ZWSP, BOM, U+2028/2029).stripControlCharsDeepwalks a lesson-shaped record: prose keys (summary,body) keep their line breaks, every other string is folded to one line.coach.importSharedLessonstrips the whole payload before it compares or stores anything, then re-validates it withSharedLessonSchema. A lesson left without a title, topic or id is refused (invalid: 1). The legacy JSON-array form thatimportalso accepts goes through the same strip.printable()incommands.ts:lessons,lesson,import,courses,course). The same goes forshare's text output, topic names inprofile/stats/feedback, anddoctor's last skip reason, which the model writes. This covers rows stored before this fix.src/cli/term.ts:displayWidth(): per grapheme viaIntl.Segmenter. Emoji presentation and East Asian Wide/Fullwidth count 2, combining marks and zero-width characters 0.pad,renderTableandruleuse it, so the 💡 row inlessonsaligns.sanitizeForTerminal()/sanitizeLine(): the CLI-facing names of the strip, for PR 7's renderer.trustedLink()(waslink()): wraps only a lowercasehttp(s)://URL with no whitespace, control or invisible format character (the same strip must leave it unchanged), at most 2 KiB. Anything else prints as plain text, and the label is sanitised too.NO_COLORwins.FORCE_COLORforces colour (even on a pipe) unless it is0/false.TERM=dumbdisables colour unless forced.termWidth(): explicit width, then the terminal's, thenCOLUMNS, then 80.readingWidth()caps it at 100.rule()is now left-anchored:── text ───….docs/usage/sharing.md(imported text is stored inert; the example table regenerated, it showed the misaligned row),docs/usage/cli.md(FORCE_COLOR,FORCE_HYPERLINK,COLUMNS),CLAUDE.md.Decisions
util.stripVTControlCharactersor a regex. Node's built-in leaves visible residue on DCS strings, 8-bit OSC and some ST-terminated OSC 8 links. A first version used one ECMA-48 regex; SonarCloud failed the reliability gate on it (S6324, control characters in a regex, the rule the maintainer marked won't fix onterm.ts's ANSI regex). Its complexity was also 32 against the allowed 20. The scanner has the same behaviour (the same adversarial suite passes) and is linear: a test bounds 256 KiB of hostile input. Dropping every leftover control character is the actual guarantee: no ESC, C1 or BEL survives, so nothing a terminal could interpret survives. Removing whole sequences only keeps their parameters from showing up as text.ESC ]cannot swallow the rest of a lesson.topic_id, categories, the context fields, andorigin.id, which becomes the local lesson id.restoreis not stripped: a backup is the user's own data, and the print-time strip covers the CLI anyway.rule()width: the terminal width capped at 100, or 80 on a pipe withoutCOLUMNS. Visible change: thelesson <id>,course <id>andsetupheaders go from a 60-column centred rule to a left-anchored one. The title stays inside the rule until PR 7 moves it out.FORCE_COLOR=""counts as forced (as in Node andsupports-color).NO_COLOR=""counts as unset (no-color.org).styleTextruns withvalidateStream: false, so Node's own check, which letsFORCE_COLORbeatNO_COLORand prints a warning, never runs. The hook and MCP paths never use the styling helpers, so their JSON stays plain whateverFORCE_COLORsays.Tests
tests/term.test.ts(rewritten, 81 cases):javascript:/file:with BEL and ST terminators; OSC 52 (BEL/ST, base64 with+/=); OSC 0; DCS/APC/PM/SOS; 8-bit CSI/OSC/DCS/APC with 8-bit ST; RIS and charset switches; bare C0/DEL/C1; RLO, isolates, LRM/RLM/ALM, ZWSP, BOM, line separators; unterminated strings. An exhaustive loop checks every C0/DEL/C1 code point alone and next to text, and text that must survive (newlines, tabs, ZWJ family, CJK, accents, markdown) is unchanged.trustedLinkwith 10 hostile URLs. A timing bound on 256 KiB of hostile input. Colour decision table, and the decision reachingc.bold(vi.resetModules+vi.stubEnv).displayWidthfor emoji, ZWJ, flags, skin tones, keycaps, VS15/VS16, combining marks, CJK, Hangul, fullwidth.renderTablewith wide glyphs: every line has the same width and its borders in the same columns.termWidthprecedence, andrule()widths with wide text.tests/core.test.ts:importSharedLessonstores every field inert (no match for the v2 control regex). The same hostile share re-imported is a duplicate, an escapes-only title is refused, the legacy array is stripped. PlusstripControlCharsDeepon plain, null-prototype and non-plain values.tests/cli.test.ts:devcoach import, thenlessons,lessonandshare: no control character in any output;lessons,lesson,share,feedback know,profileandstats;lessonstable aligning with 💡 and CJK titles.With the print-time strip disabled, the raw-row and course tests fail. With it on and the import strip off, the core test fails.
npm run lint: clean (the 3useTemplateinfos intests/cli.test.tsare pre-existing).npm run typecheck: clean.npm run test:cov: 470 tests pass. Coverage 94.97 lines / 93.77 statements / 96.93 functions / 84.72 branches (thresholds 92 / 91 / 95 / 76).85 statements / 97.03 functions / 84.72 branches (thresholds 92 / 91 / 95 / 76).term.tsandtext.ts100 % lines.The built CLI (
npm run build,node dist/bin.js) against the crafted code on a sandbox writes 0 ESC and 0 BEL bytes forimport,lessonsandlesson, and the stored row is clean.CHANGELOG
Suggested lines, not edited here: the request-guard PR adds the Security heading to Unreleased, so these go under it at merge time.
Before / after
Slideshow — each screen BEFORE (red band: without this PR), then AFTER (green band: with this PR), with one line saying what changed. It plays by itself; click it to open it full size.
Dark
Videos — the first frame is the title; a full red BEFORE or green AFTER card opens every segment, and the band in the same colour stays above the recording until the segment ends.
Light mode, real CLI output rendered in xterm.js. BEFORE (develop, 2.7.1): in
devcoach lessonsthe 💡 row's borders sit one column right of the header's. Importing a crafted shared lesson erases the screen and the scrollback, anddevcoach lessonerases them again, then prints two hidden OSC 8 links (javascript:alert(1), file:///etc/passwd) and an OSC 52 clipboard write: 10 escape sequences in all. AFTER (this branch): the table lines up, and the same lesson prints as plain text; no escape sequence reaches the terminal.pr-134-before-after-light.mp4
Dark mode, same script. BEFORE (develop, 2.7.1): in
devcoach lessonsthe 💡 row's borders sit one column right of the header's. Importing a crafted shared lesson erases the screen and the scrollback, anddevcoach lessonerases them again, then prints two hidden OSC 8 links (javascript:alert(1), file:///etc/passwd) and an OSC 52 clipboard write: 10 escape sequences in all. AFTER (this branch): the table lines up, and the same lesson prints as plain text; no escape sequence reaches the terminal.pr-134-before-after-dark.mp4
Side by side — BEFORE on the left, AFTER on the right.
devcoach lessonszoomed on the 💡 row, light. Before: from the end of the title on, every border sits one column right of the header's. After: the borders line up.Dark
devcoach lessonon the imported lesson, light. Before: the screen was erased, “the BuildKit guide” is a live link to file:///etc/passwd, and the terminal received 10 escape sequences. After: the whole session is still on screen, the text is plain, and no sequence was received.Dark
Not verified
file:link depends on its settings.🤖 Generated with Claude Code